﻿using System;
using System.Linq;
using System.Reflection;
using System.Reflection.Emit;
using Newtonsoft.Json.Schema;

namespace RuntimeTypeGenerator
{
    /// <summary>
    /// Type building extensions for the <see cref="JsonSchema"/> class.
    /// </summary>
    public static class JsonSchemaTypeBuilderExtensions
    {
        /// <summary>
        /// Build a type from the <paramref name="schema"/> into <paramref name="assembly"/>.
        /// </summary>
        /// <param name="schema">The schema.</param>
        /// <param name="assembly">The generated assembly to build the type into.</param>
        /// <param name="typeName">The type name override, needed when <paramref name="schema"/>'s Id is not set.</param>
        /// <returns>The dynamically generated type.</returns>
        public static Type BuildTo(this JsonSchema schema, ITypeBuildTarget buildTarget, string typeName = null)
        {
            return buildTarget.BuildWith(new JsonSchemaTypeBuilder(schema, typeName));
        }
    }

    /// <summary>
    /// Defines a type builder that uses a <see cref="JsonSchema"/> as metadata.
    /// </summary>
    public class JsonSchemaTypeBuilder : ITypeBuilder
    {
        /// <summary>
        /// Gets the type name generated by this builder.
        /// </summary>
        public string TypeName { get; private set; }

        /// <summary>
        /// Gets the JSON schema this builder uses for type generation.
        /// </summary>
        public JsonSchema Schema { get; private set; }

        /// <summary>
        /// Create a type builder from the provided JSON <paramref name="schema"/>.
        /// </summary>
        /// <param name="schema">The JSON schema.</param>
        /// <param name="typeName">Type name override, required when schema's Id is not set.</param>
        /// <exception cref="ArgumentNullException">Occurs when schema or schema.Type is <c>null</c></exception>
        /// <exception cref="ArgumentException">Occurs when schema Id is not set and no <paramref name="tpyeName"/> was provided.</exception>
        public JsonSchemaTypeBuilder(JsonSchema schema, string typeName = null)
        {
            if (schema == null)
            {
                throw new ArgumentNullException("schema");
            }

            if (!schema.Type.HasValue)
            {
                throw new ArgumentNullException("schema.Type");
            }

            Schema = schema;
            if (!string.IsNullOrEmpty(typeName))
            {
                TypeName = typeName;
            }
            else if (!string.IsNullOrEmpty(Schema.Id))
            {
                TypeName = Schema.Id;
            }
            else
            {
                throw new ArgumentException("Either schema.Id or typeName parameter must contain a value .");
            }
        }

        /// <summary>
        /// Builds the type into the provided <paramref name="moduleBuilder"/>.
        /// </summary>
        /// <param name="moduleBuilder">The target of the type build.</param>
        /// <returns>The built type.</returns>
        public Type BuildType(ModuleBuilder moduleBuilder)
        {
            if (moduleBuilder == null)
            {
                throw new ArgumentNullException("moduleBuilder");
            }

            return BuildTypeFromSchema(moduleBuilder, moduleBuilder.Assembly.GetName().Name + "." + TypeName, Schema);
        }

        #region Private

        private static Type BuildTypeFromSchema(ModuleBuilder moduleBuilder, string name, JsonSchema schema)
        {
            if (schema == null)
            {
                throw new ArgumentNullException("schema");
            }

            if (!schema.Type.HasValue)
            {
                throw new ArgumentNullException("schema.Type");
            }

            switch (schema.Type.Value)
            {
                case Newtonsoft.Json.Schema.JsonSchemaType.None:
                case Newtonsoft.Json.Schema.JsonSchemaType.Any:
                    return typeof(object);
                case Newtonsoft.Json.Schema.JsonSchemaType.Array:
                    return BuildArrayFromSchema(moduleBuilder, name, schema);
                case Newtonsoft.Json.Schema.JsonSchemaType.Boolean:
                    return typeof(bool);
                case Newtonsoft.Json.Schema.JsonSchemaType.Float:
                    return typeof(Double);
                case Newtonsoft.Json.Schema.JsonSchemaType.Integer:
                    return typeof(Int32);
                case Newtonsoft.Json.Schema.JsonSchemaType.String:
                    return typeof(string);
                case Newtonsoft.Json.Schema.JsonSchemaType.Object:
                    return BuildObjectFromSchema(moduleBuilder, name, schema);
                default:
                    throw new ArgumentException("Cannot construct a CLR type from Schema type " + schema.Type.Value);
            }
        }

        private static Type BuildArrayFromSchema(ModuleBuilder moduleBuilder, string name, JsonSchema schema)
        {
            // use a non typed array
            if (schema.Items == null || schema.Items.Count == 0)
            {
                return typeof(Array);
            }
            // check all elemnts are of same schema type
            if (schema.Items.Select(s => s.Type).Distinct().Count() != 1)
            {
                return typeof(Array);
            }
            // use first element as the array's type
            return BuildTypeFromSchema(moduleBuilder, name, schema.Items[0]).MakeArrayType();
        }

        private static Type BuildObjectFromSchema(ModuleBuilder moduleBuilder, string name, JsonSchema schema)
        {
            var typeName = name;
            // prefer the schema Id to the passed name
            if (!string.IsNullOrEmpty(schema.Id))
            {
                typeName = moduleBuilder.Assembly.GetName().Name + "." + schema.Id.Trim().Replace(' ', '_');
            }

            // check if module contains this type already
            var builtType = moduleBuilder.GetType(typeName);
            if (builtType != null)
            {
                return builtType;
            }

            // define the new type
            var typeBuilder = moduleBuilder.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class | TypeAttributes.Serializable);
            // define a field for each property
            foreach (var prop in schema.Properties)
            {
                typeBuilder.DefineField(prop.Key, BuildTypeFromSchema(moduleBuilder, typeName + "." + prop.Key, prop.Value), FieldAttributes.Public);
            }
            // public default ctor
            typeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, null)
                       .GetILGenerator()
                       .Emit(OpCodes.Ret);
            // create the actual type
            return typeBuilder.CreateType();
        }

        #endregion
    }
}
